import Orb from 'orb-billing';
import { uuidv7 } from 'uuidv7';

import { Err, Ok, metrics, retry } from '@nangohq/utils';

import { envs } from '../envs.js';

import type {
    BillingClient,
    BillingCustomer,
    BillingEvent,
    BillingSubscription,
    BillingUsageMetrics,
    DBTeam,
    DBUser,
    GetBillingUsageOpts,
    Result,
    UsageMetric
} from '@nangohq/types';

export class OrbClient implements BillingClient {
    private orbSDK: Orb;

    constructor() {
        this.orbSDK = new Orb({
            apiKey: envs.ORB_API_KEY || 'empty',
            maxRetries: envs.ORB_MAX_RETRIES
        });
    }

    async ingest(events: BillingEvent[]): Promise<Result<void>> {
        // Orb limit the number of events per batch to 500
        const batchSize = 500;
        for (let i = 0; i < events.length; i += batchSize) {
            const batch = events.slice(i, i + batchSize);
            try {
                const initialDelayMs = envs.ORB_RETRY_INITIAL_DELAY_MS;
                await retry(
                    () => {
                        return this.orbSDK.events.ingest({
                            events: batch.map(toOrbEvent)
                        });
                    },
                    {
                        maxAttempts: envs.ORB_RETRY_MAX_ATTEMPTS,
                        delayMs: (attempt) => initialDelayMs * 2 ** attempt + Math.random() * initialDelayMs, // exponential backoff with jitter
                        retryOnError: (e) => {
                            // retry only on 429
                            if (e instanceof Orb.APIError) {
                                return e.status === 429;
                            }
                            return false;
                        }
                    }
                );
                metrics.increment(metrics.Types.ORB_BILLING_EVENTS_INGESTED, batch.length, { success: 'true' });
            } catch (err) {
                metrics.increment(metrics.Types.ORB_BILLING_EVENTS_INGESTED, batch.length, { success: 'false' });
                return Err(new Error('failed_to_ingest_events', { cause: err }));
            }
        }
        return Ok(undefined);
    }

    async upsertCustomer(team: DBTeam, user: DBUser): Promise<Result<BillingCustomer>> {
        try {
            let exists: Orb.Customers.Customer | null = null;
            try {
                exists = await this.orbSDK.customers.fetchByExternalId(String(team.id));
            } catch {
                // expected error
            }

            if (exists) {
                await this.orbSDK.customers.update(exists.id, {
                    name: team.name
                });
                return Ok({ id: exists.id, portalUrl: exists.portal_url });
            }

            const customer = await this.orbSDK.customers.create({
                external_customer_id: String(team.id),
                currency: 'USD',
                name: team.name,
                email: user.email
            });
            return Ok({ id: customer.id, portalUrl: customer.portal_url });
        } catch (err) {
            return Err(new Error('failed_to_upsert_customer', { cause: err }));
        }
    }

    async updateCustomer(customerId: string, name: string): Promise<Result<void>> {
        try {
            await this.orbSDK.customers.update(customerId, { name });
            return Ok(undefined);
        } catch (err) {
            return Err(new Error('failed_to_update_customer', { cause: err }));
        }
    }

    async linkStripeToCustomer(teamId: number, customerId: string): Promise<Result<void>> {
        try {
            await this.orbSDK.customers.updateByExternalId(String(teamId), {
                payment_provider: 'stripe_charge',
                payment_provider_id: customerId,
                auto_collection: true
            });
            return Ok(undefined);
        } catch (err) {
            return Err(new Error('failed_to_link_customer', { cause: err }));
        }
    }

    async getCustomer(accountId: number): Promise<Result<BillingCustomer>> {
        try {
            const customer = await this.orbSDK.customers.fetchByExternalId(String(accountId));
            return Ok({ id: customer.id, portalUrl: customer.portal_url });
        } catch (err) {
            return Err(new Error('failed_to_get_customer', { cause: err }));
        }
    }

    async createSubscription(team: DBTeam, planExternalId: string): Promise<Result<BillingSubscription>> {
        try {
            // We want to backdate the subscription to the day the team was created to backfill the usage
            // Orb doesn't allow to backdate the subscription by more than 95 days
            // Use `upgrade` to change the subscription without backdating
            const minStartDate = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000);
            const startDate = new Date(Math.max(team.created_at.getTime(), minStartDate.getTime())).toISOString();

            const subscription = await this.orbSDK.subscriptions.create({
                external_customer_id: String(team.id),
                external_plan_id: planExternalId,
                start_date: startDate
            });
            return Ok({ id: subscription.id, planExternalId: planExternalId });
        } catch (err) {
            return Err(new Error('failed_to_create_subscription', { cause: err }));
        }
    }

    async getSubscription(accountId: number): Promise<Result<BillingSubscription | null>> {
        try {
            const subs = await this.orbSDK.subscriptions.list({ external_customer_id: [String(accountId)], status: 'active' });
            if (subs.data.length === 0) {
                return Ok(null);
            }

            const sub = subs.data[0]!;
            return Ok({
                id: sub.id,
                pendingChangeId: sub.pending_subscription_change?.id,
                planExternalId: sub.plan?.external_plan_id || ''
            });
        } catch (err) {
            return Err(new Error('failed_to_get_customer', { cause: err }));
        }
    }

    async getUsage(subscriptionId: string, opts?: GetBillingUsageOpts): Promise<Result<BillingUsageMetrics>> {
        try {
            const options: Orb.Subscriptions.SubscriptionFetchUsageParams = {};
            if (opts?.timeframe) {
                options.timeframe_start = opts.timeframe.start.toISOString();
                options.timeframe_end = opts.timeframe.end.toISOString();
            }
            if (opts?.granularity) {
                options.granularity = 'day';
            }
            if (opts?.billingMetric) {
                options.billable_metric_id = opts.billingMetric.id;
                if (opts.billingMetric.group_by) {
                    options.group_by = opts.billingMetric.group_by;
                }
            }

            const res = await this.orbSDK.subscriptions.fetchUsage(subscriptionId, options, {
                // https://docs.withorb.com/api-reference/cached-responses
                headers: {
                    'Orb-Cache-Control': 'cache',
                    'Orb-Cache-Max-Age-Seconds': '60'
                }
            });

            const entries: BillingUsageMetrics = {};

            for (const item of res.data) {
                const usageMetric = orbMetricToUsageMetric(item.billable_metric.name);
                if (!usageMetric) {
                    continue;
                }

                const group =
                    'metric_group' in item
                        ? {
                              group: {
                                  key: item.metric_group.property_key,
                                  value: item.metric_group.property_value
                              }
                          }
                        : {};

                entries[usageMetric] = {
                    ...group,
                    externalId: item.billable_metric.id,
                    total: item.usage.reduce((sum, u) => sum + u.quantity, 0),
                    usage: item.usage.map((u) => {
                        return {
                            timeframeStart: new Date(u.timeframe_start),
                            timeframeEnd: new Date(u.timeframe_end),
                            quantity: u.quantity
                        };
                    }),
                    view_mode: 'periodic'
                };
            }

            return Ok(entries);
        } catch (err) {
            return Err(new Error('failed_to_get_usage', { cause: err }));
        }
    }

    async upgrade(opts: { subscriptionId: string; planExternalId: string }): Promise<Result<{ pendingChangeId: string; amountInCents: number | null }>> {
        try {
            // We schedule the upgrade but we don't apply it yet
            // We apply it when the first payment is made to confirm the card
            const pendingUpgrade = await this.orbSDK.subscriptions.schedulePlanChange(
                opts.subscriptionId,
                {
                    change_option: 'immediate', // It will be immediate after first payment
                    auto_collection: true,
                    external_plan_id: opts.planExternalId
                },
                { headers: { 'Create-Pending-Subscription-Change': 'true' } }
            );

            // Invoices created are ordered by due date
            // The first one is the pending one (if there was one) and the second is what we will charge
            // Since the order and numbers are unreliable, we need to find the one that is due today
            let amountDue = 0;
            for (const invoice of pendingUpgrade.changed_resources?.created_invoices || []) {
                if (!invoice.due_date) {
                    continue;
                }
                if (new Date(invoice.due_date).getTime() > Date.now()) {
                    continue;
                }
                if (invoice.amount_due === '0.00') {
                    continue;
                }
                amountDue = Number(invoice.amount_due) * 100;
                break;
            }

            return Ok({
                pendingChangeId: pendingUpgrade.pending_subscription_change!.id,
                // We return the amount due for the first invoice, it's the pending one that contains the pro-rated amount if any
                amountInCents: amountDue || null
            });
        } catch (err) {
            return Err(new Error('failed_to_upgrade_customer', { cause: err }));
        }
    }

    async downgrade(opts: { subscriptionId: string; planExternalId: string }): Promise<Result<void>> {
        try {
            await this.orbSDK.subscriptions.schedulePlanChange(opts.subscriptionId, {
                change_option: 'end_of_subscription_term',
                auto_collection: true,
                external_plan_id: opts.planExternalId
            });

            return Ok(undefined);
        } catch (err) {
            return Err(new Error('failed_to_upgrade_customer', { cause: err }));
        }
    }

    async applyPendingChanges(opts: { pendingChangeId: string; amount: string }): Promise<Result<BillingSubscription>> {
        try {
            // We apply the pending change to confirm the card
            const res = await this.orbSDK.subscriptionChanges.apply(opts.pendingChangeId, {
                description: 'Initial payment on subscription',
                previously_collected_amount: opts.amount
            });
            if (!res.subscription) {
                return Err(new Error('failed_to_apply_pending_changes', { cause: 'no subscription' }));
            }

            return Ok({
                id: res.subscription.id,
                planExternalId: res.subscription.plan!.external_plan_id!
            });
        } catch (err) {
            return Err(new Error('failed_to_apply_pending_changes', { cause: err }));
        }
    }

    async cancelPendingChanges(opts: { pendingChangeId: string }): Promise<Result<void>> {
        try {
            await this.orbSDK.subscriptionChanges.cancel(opts.pendingChangeId);

            return Ok(undefined);
        } catch (err) {
            return Err(new Error('failed_to_cancel_pending_changes', { cause: err }));
        }
    }

    verifyWebhookSignature(body: string, headers: Record<string, unknown>, secret: string): Result<true> {
        try {
            this.orbSDK.webhooks.verifySignature(body, headers as any, secret);

            return Ok(true);
        } catch (err) {
            return Err(new Error('failed_to_verify_signature', { cause: err }));
        }
    }

    async getPlanById(planId: string): Promise<Result<{ id: string; external_plan_id: string }>> {
        try {
            const plan = await this.orbSDK.plans.fetch(planId);

            return Ok({ id: plan.id, external_plan_id: plan.external_plan_id! });
        } catch (err) {
            return Err(new Error('failed_to_get_plan_by_id', { cause: err }));
        }
    }
}

function toOrbEvent(event: BillingEvent): Orb.Events.EventIngestParams.Event {
    const { idempotencyKey, timestamp, accountId, ...rest } = event.properties;

    // orb doesn't accept nested properties, we need to flatten them with dot notation
    const properties: Record<string, string | number | boolean> = {};
    for (const [topLevelKey, value] of Object.entries(rest)) {
        if (!value) continue;
        if (typeof value === 'object') {
            for (const [k, v] of Object.entries(value)) {
                properties[`${topLevelKey}.${k}`] = v;
            }
        } else {
            properties[topLevelKey] = value;
        }
    }

    return {
        event_name: event.type,
        idempotency_key: idempotencyKey || uuidv7(),
        external_customer_id: accountId.toString(),
        timestamp: timestamp.toISOString(),
        properties
    };
}

function orbMetricToUsageMetric(name: string): UsageMetric | null {
    // Not ideal to match on BillingMetric name but Orb only exposes the user friendly name or internal ids
    const lowerName = name.toLowerCase();
    // order matters here
    if (lowerName.includes('legacy')) return null;
    if (lowerName.includes('logs')) return 'function_logs';
    if (lowerName.includes('proxy')) return 'proxy';
    if (lowerName.includes('forward')) return 'webhook_forwards';
    if (lowerName.includes('compute')) return 'function_compute_gbms';
    if (lowerName.includes('function')) return 'function_executions';
    if (lowerName.includes('connections')) return 'connections';
    if (lowerName.includes('records')) return 'records';

    return null;
}
